我最近試著把家庭任務平台的前後端分離時,後端要開出API給前端來抓取資料,但因為家庭任務平台會有權限限制,例如只有建立計畫的人才能看到計畫,Laravel有提供一個方便的套件Sanctum來處理這樣的狀況。
Sanctum可以提供單頁應用程式認證、手機應用程式、APIs的Token認證。單頁應用程式認證和APIs的Token認證採取不同的機制:(1)單頁應用程式認證: cookie based session;(2)APIs的Token認證:Bear Token。開發者可以根據自己的狀況任選其中一個機制來使用,但如果可以適用單頁應用程式認證,即前端和後端的頂級網域名稱相同,則建議使用單頁應用程式認證,因為其提供的防護如防CSRF更為周全。
由於前端並不擁有相同的頂級網域名稱,我們使用APIs的Token認證:
安裝Sanctumcomposer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
利用User的HasApiTokens來發Token
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}
$token = $user->createToken('token-name');
return $token->plainTextToken;
$user->tokens()->delete();
// Revoke the user's current token...
$request->user()->currentAccessToken()->delete();
// Revoke a specific token...
$user->tokens()->where('id', $id)->delete();
token-name:自取。
class AuthController extends Controller
{
public function register(RegisterRequest $request)
{
$validatedData = $request->validated();
$validatedData['password'] = Hash::make(request('password'));
$user = User::create($validatedData);
return $this->userResponse($user, 201);
}
public function login(LoginRequest $request)
{
$validatedData = $request->validated();
if (!Auth::attempt($validatedData)) {
return response()->json(
["message" => "The credential was invalid."],
401
);
}
$user = User::where('email', $validatedData['email'])->first();
return $this->userResponse($user, 200);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json([],204);
}
protected function userResponse(User $user, int $status)
{
$token = $user->createToken('familyboard-apis');
return response()->json(['accessToken' => $token->plainTextToken, 'type' => 'Bearer'], $status);
}
}
class RegisterRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
];
}
public function messages()
{
return [
'name.required' => 'A name is required',
'email.required' => 'A email is required',
'email.unique'=>'The email already exists',
'password.required' => 'A password is required',
];
}
}
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'email' => ['required', 'string', 'email', 'max:255', 'exists:users'],
'password' => ['required', 'string', 'min:8'],
];
}
public function messages()
{
return [
'email.required' => 'A email is required',
'email.exists'=>'The email is not registered yet',
'password.required' => 'A password is required',
];
}
}
class LoginAndRegisterTest extends TestCase
{
use RefreshDatabase, WithFaker;
/**
* A basic feature test example.
*
* @test
*/
public function a_registered_user_can_get_a_token()
{
$response = $this->post(
route('register'),
[
'name' => 'jhao',
'email' => 'jhao@gmail.com',
'password' => '12345678',
'password_confirmation' => '12345678'
]
);
$this->assertDatabaseHas('users', ['email' => 'jhao@gmail.com']);
$this->assertDatabaseHas(
'personal_access_tokens',
[
'tokenable_type' => 'App\Models\User',
'tokenable_id' => User::where('name', 'jhao')->first()->id
]
);
$response->assertStatus(201);
}
/** @test */
public function registering_twice_would_receive_status_422()
{
$user = User::factory()->create();
$response = $this->post(
route('register'),
[
'name' => $user->name,
'email' => $user->email,
'password' => '12345678',
'password_confirmation' => '12345678'
],
);
$response->assertStatus(422);
$response->assertExactJson(["message" => "The given data was invalid.", "errors" => ["email" => ["The email already exists"]]]);
}
/** @test */
public function logged_in_user_must_have_a_registered_email()
{
$response = $this->post(route('login'), ['email' => 'jhao@gmail.com', 'password' => '12345678']);
$response->assertStatus(422);
$response->assertExactJson(["message" => "The given data was invalid.", "errors" => ["email" => ["The email is not registered yet"]]]);
}
/** @test */
public function logged_in_user_must_have_valid_credential()
{
$user = User::factory()->create(['password' => '1234567']);
$response = $this->post(route('login'), ['email' => $user->email, 'password' => '1qaz2wsx']);
$response->assertStatus(401);
$response->assertExactJson(["message" => "The credential was invalid."]);
}
/** @test */
public function logged_in_user_can_get_an_access_token()
{
$user = User::factory()->create(['password' => Hash::make('12345678'), 'name' => 'jhao']);
$response = $this->post(route('login'), ['email' => $user->email, 'password' => '12345678']);
$response->assertStatus(200);
}
}
Route::prefix('v1')->group(function () {
Route::post('register', [AuthController::class, 'register'])->name('register');
Route::post('login', [AuthController::class, 'login'])->name('login');
});
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
Route::get('logout', [AuthController::class, 'logout'])->name('logout');
Route::apiResource('projects',ProjectController::class);
});
此外,為了讓api路由都會在Header中加入['Accept', 'application/json'],做一個AddJsonHeader的Middleware。
class AddJsonHeader
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
$request->headers->set('Accept', 'application/json');
return $next($request);
}
}
'api' => [
\App\Http\Middleware\AddJsonHeader::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
class LoginAndRegisterTest extends TestCase
{
...
/** @test */
public function logged_out_user_can_not_access_website()
{
$user = Sanctum::actingAs(
User::factory()->create(),
['*']
);
$this->get(route('projects.index'))->assertSuccessful();
$this->get(route('logout'))->assertStatus(204);
$this->assertDatabaseMissing(
'personal_access_tokens',
[
'tokenable_id' => $user->id,
'tokenable_type' => 'App\Models\User'
]
);
}
...
}
$user = Sanctum::actingAs( User::factory()->create(), ['*'] );
參考文章
Laravel Sanctum